Skip to content

Generate the vector forms in the C++ projection - #215

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/blissful-euler-fzuap2
Sep 11, 2026
Merged

Generate the vector forms in the C++ projection#215
matt-edmondson merged 1 commit into
mainfrom
claude/blissful-euler-fzuap2

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

dimensions.json declares 122 dimension-and-form entries and the C++ projection covered 72 of them. The other 50 are the vector forms, and a vocabulary that can say Speed but not Velocity3D is not much use to a renderer.

212 classes, and each form is one of its own

148 magnitudes, 27 signed scalars, 37 vectors of two to four components — plus the overloads that refine them.

A vector form is a class, not an alias for Vector3<Quantity<D>>. It holds its components, names them x through w, and is exactly its components in memorysizeof is three floats, trivially copyable, standard layout — because Holotype copies one whole across a language boundary and onto the wire.

class Displacement3D
{
public:
    using component = Quantity<Dimension<1>>;

    explicit constexpr Displacement3D(component x, component y, component z) noexcept
        : x_(x), y_(y), z_(z) {}

    [[nodiscard]] Length magnitude() const noexcept { return Length{ sqrt(magnitude_squared()) }; }
    [[nodiscard]] constexpr Quantity<Dimension<2>> magnitude_squared() const noexcept { return x_ * x_ + y_ * y_ + z_ * z_; }

    [[nodiscard]] friend constexpr Displacement3D operator+(Displacement3D lhs, Displacement3D rhs) noexcept
    {
        return Displacement3D{ lhs.x_ + rhs.x_, lhs.y_ + rhs.y_, lhs.z_ + rhs.z_ };
    }
    ...

Rule four, and why it is free here

Componentwise arithmetic is written out, not looped. That is the fourth of the four measured rules, and the one nothing had yet obeyed because nothing had yet needed to — a loop over a runtime subscript is what took the same spike from 1.01 to 4.51 on MSVC.

Obeying it costs a generator nothing, and that is worth writing down: a hand-written library spells Vector3<Q> once over every Q, so expanding rather than looping means an index-sequence fold and the machinery around it. A generator has the components in hand while it writes the class, so the expanded form is simply what there is to write.

The dimension works out rather than being arranged

Each signed form answers magnitude() with the magnitude form of the same dimension. The sum of the squares of the components has twice a component's dimension and sqrt halves it again — so the bridge between the signed and unsigned halves of the vocabulary is something the compiler checks rather than something the generator asserts.

magnitude_squared() answers with a bare Quantity for the honest reason: the square of a dimension usually has no name, and where it has one it is not unique, since Area and NuclearCrossSection are the same exponents. That is exactly what the structural layer is for.

How a relationship reaches the vector forms

It carries its form on the left operand and the result, with the right operand staying a magnitude:

[[nodiscard]] constexpr Displacement3D operator*(Velocity3D lhs, Duration rhs) noexcept
{
    return Displacement3D{ lhs.x() * rhs.value(), lhs.y() * rhs.value(), lhs.z() * rhs.value() };
}

There is no reading in which the duration has three components. That is the rule the .NET generator already follows, and forms on a relationship constrains it — a cross product is declared at [3] because that is where a cross product exists.

⚠️ A fifth refusal, of a second kind

dot(Force, Length) -> Energy is dimensionally true and still unkeepable. A force opposing a displacement does negative work; a magnitude form cannot be negative. The generated operator would build a type that fails its own assertion on an ordinary input.

So it is refused, with the fix named rather than guessed at:

dot(Force, Length) -> Energy: reduces to a signed value -- two vectors that oppose
each other give a negative one -- and 'Energy' declares only a magnitude form, which
cannot be negative. A vector1 form on it is what would let this be generated.

Until now every refusal was the exponents disagreeing. This is the first one where they agree and the claim is still wrong, and the vector forms are what surfaced it.

⚠️ One thing the exponents cannot check, left alone

Force × Length → Torque emits as cross(Force3D, Displacement3D) — that is F × r, and the convention is τ = r × F. A cross product and its negation have identical dimensions, so nothing here can tell them apart. Which operand comes first is a claim the metadata makes and a physics call to change, the same as Sensitivity, so it is emitted as declared and reported rather than quietly reordered.

Tests

Eleven more, 27 in the project, and two of them are the ones that matter:

  • TheWholeVocabularyCompiles — 216 headers through g++ -std=c++20 -Wall -Wextra, clean. (Also verified against clang++.)
  • AComponentwiseProductWithTheWrongDimensionDoesNotCompile — the case that would slip past a test which only looked at the shape: the components can be expanded perfectly and the dimension still be wrong.
  • TheVectorFormsMeanWhatTheySay — what the forms mean, not that they parse, entirely in static_assert so it needs no run: that the length of (3, 4, 0) is 5, that a velocity scaled by a duration lands in the right type and the right component, that an overload survives the trip out to its base and back, that a Displacement3D is three floats and nothing else.

Existing suite: 1,125 passed, unchanged. dimensions.json is untouched this time, so no generated C# moves.

Two deliberate boundaries

  • Arithmetic belongs to the signed forms and stops there. Length - Length has a question in it that Displacement3D - Displacement3D does not — what it means when the answer would be negative — which the .NET side settled as the absolute difference. That is a decision about the magnitude form, not one to settle alongside the vectors.
  • A relationship is emitted in the direction the metadata declares it, so Duration * Velocity3D is not an overload. The .NET generator emits the commutative and inverse forms too; matching it is a change to every form at once rather than part of this.

The prelude gains abs, written out rather than calling std::abs, which is not constexpr before C++23. Nothing existing calls it, so the measured shapes are unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu


Generated by Claude Code

dimensions.json declares 122 dimension-and-form entries and the projection
covered 72 of them. The other 50 are the vector forms, and a vocabulary that
can say Speed but not Velocity3D is not much use to a renderer.

212 classes now: 148 magnitudes, 27 signed scalars, and 37 vectors of two to
four components, plus the overloads that refine them. A vector form is a class
of its own, not an alias for Vector3<Quantity<D>>: it holds its components,
names them x through w, and is exactly its components in memory - sizeof is
three floats, trivially copyable, standard layout - because Holotype copies one
whole across a language boundary and onto the wire.

Componentwise arithmetic is written out rather than looped, which is the fourth
measured rule and the one nothing had yet obeyed because nothing had yet needed
to. Obeying it costs a generator nothing, and that is worth writing down: a
hand-written library spells Vector3<Q> once over every Q, so expanding rather
than looping means an index-sequence fold and the machinery around it; a
generator has the components in hand while it writes the class, so the expanded
form is simply what there is to write.

Each signed form answers magnitude() with the magnitude form of the same
dimension, and the dimension works out rather than being arranged: the sum of
the squares has twice a component's dimension and sqrt halves it again. That
makes the bridge between the signed and unsigned halves of the vocabulary
something the compiler checks. magnitude_squared() answers with a bare Quantity
for the honest reason - the square of a dimension usually has no name, and where
it has one it is not unique, since Area and NuclearCrossSection are the same
exponents.

A relationship reaches the vector forms by carrying its form on the left operand
and the result, with the right operand staying a magnitude: Velocity3D * Duration
-> Displacement3D. There is no reading in which the duration has three
components. That is the rule the .NET generator already follows, and `forms` on
a relationship constrains it - a cross product is declared at [3] because that
is where a cross product exists.

A fifth relationship is refused, and it is a second kind of refusal that the
vector forms are what surfaced. dot(Force, Length) -> Energy is dimensionally
true and still unkeepable: a force opposing a displacement does negative work,
and a magnitude form cannot be negative, so the generated operator would build a
type that fails its own assertion on an ordinary input. The message names the
fix rather than guessing at it - Energy needs a vector1 form for the result to
land in.

One thing the exponents cannot check is left alone rather than quietly changed.
Force x Length -> Torque emits as cross(Force3D, Displacement3D), which is F x r,
and the convention is tau = r x F. A cross product and its negation have
identical dimensions, so nothing here can tell them apart; which operand comes
first is a claim the metadata makes and a physics call to change, the same as
Sensitivity.

Eleven more tests, and two of them are the ones that matter. The whole
vocabulary still compiles clean under g++ and clang++ with -Wall -Wextra, and a
componentwise product whose exponents disagree with its result must not compile -
the case that would slip past a test which only looked at the shape, because the
components can be expanded perfectly and the dimension still be wrong. A third
asserts what the forms mean rather than that they parse, entirely in
static_assert, so it needs no run: that the length of (3, 4, 0) is 5, that a
velocity scaled by a duration lands in the right type and the right component,
that an overload survives the trip out to its base and back.

Two boundaries are deliberate. Arithmetic belongs to the signed forms and stops
there: Length - Length has a question in it that Displacement3D - Displacement3D
does not, which the .NET side settled as the absolute difference and which is a
decision about the magnitude form rather than one to settle alongside the
vectors. And a relationship is emitted in the direction the metadata declares
it, so Duration * Velocity3D is not an overload; the .NET generator emits the
commutative and inverse forms too, and matching it is a change to every form at
once rather than part of this.

The prelude gains abs, written out rather than calling std::abs, which is not
constexpr before C++23. Nothing existing calls it, so the measured shapes are
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 570d3a7 into main Sep 11, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/blissful-euler-fzuap2 branch September 11, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant